Fork me on GitHub

Compiler

package cas.cs4tb3.mellowd.compiler;

import cas.cs4tb3.mellowd.TimingEnvironment;
import cas.cs4tb3.mellowd.midi.GeneralMidiConstants;
import cas.cs4tb3.mellowd.parser.MellowDLexer;
import cas.cs4tb3.mellowd.parser.MellowDParser;
import cas.cs4tb3.mellowd.parser.ParseException;
import cas.cs4tb3.mellowd.parser.TrackManager;
import net.sourceforge.argparse4j.ArgumentParsers;
import net.sourceforge.argparse4j.impl.Arguments;
import net.sourceforge.argparse4j.inf.*;
import org.antlr.v4.runtime.*;
import org.antlr.v4.runtime.misc.ParseCancellationException;

import javax.sound.midi.*;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Paths;
import java.util.List;
import java.util.concurrent.TimeUnit;

The Compiler class is the main entry point for the program.

public class Compiler {

Empty sequences will have this event appended to create a playable empty sequence.

    private static final class ImmutableEndOfTrack extends MetaMessage {
        private static final byte EOT_EVENT_CODE = 0x2F;

        private ImmutableEndOfTrack() {
            super(new byte[3]);
            data[0] = (byte) META;
            data[1] = EOT_EVENT_CODE;
            data[2] = 0;
        }

        public void setMessage(int type, byte[] data, int length) throws InvalidMidiDataException {
            throw new InvalidMidiDataException("cannot modify end of track message");
        }
    }

    public static final MidiMessage EOT_MESSAGE = new ImmutableEndOfTrack();
    public static final String FILE_EXTENSION = ".mlod";

    public static void main(String[] args) {

The ArgumentParser is from the argparse4j. It is a library for parsing command line arguments.

Create a new parser for the mellowd command. The defaultHelp create a -h option that displays a help menu for the command.

        ArgumentParser argParser = ArgumentParsers.newArgumentParser("mellowd")
                .defaultHelp(true)
                .version("1.0.0");

The -p option is a flag so the action will be storeTrue so that the value of this argument will be false if not present and true if it is present. If -p is set the program will immediately playback the compiled song.

        argParser.addArgument("-p", "--play")
                .action(Arguments.storeTrue())
                .help("Playback the specified file instead of exporting it.");

The -o option specifies the output directory. By default this is just the current directory. The type of the argument is a file so the parser tries to parse a path.

        argParser.addArgument("-o", "--outdir")
                .nargs("?")
                .setDefault(Paths.get("").toAbsolutePath().toFile())
                .type(Arguments.fileType())
                .help("Set the output directory for the compilation.");

The -ts option specifies the time signature. This is 44 by default. To specify a new time signature both the numerator and denominator must be given. Otherwise neither need to be given.

        argParser.addArgument("-ts", "--timesig")
                .nargs(2)
                .type(byte.class)
                .setDefault(4, 4)
                .required(false)
                .metavar("numerator", "denominator")
                .help("Specify the numerator and denominator of the time signature (Ex: 4 4 for 4/4).");

The -t options specifies the tempo. A standard tempo is 120 bpm so that is the default. To change the tempo the -t argument must be followed by a number that represents the tempo in beats per minute.

        argParser.addArgument("-t", "--tempo")
                .nargs("?")
                .metavar("tempo")
                .type(int.class)
                .setDefault(120)
                .help("Specify the tempo of the compiled song in BPM.");

There are various flags that can specify what output formats to write the file as.

        MutuallyExclusiveGroup outputFormat = argParser.addMutuallyExclusiveGroup("output format")
                .description("Set the output format of the compiler. This defaults to MIDI (.mid)");
        outputFormat.addArgument("--mid", "--midi")
                .dest("IODelegate")
                .action(Arguments.storeConst())
                .setConst(MIDIIODelegate.getInstance())
                .setDefault(MIDIIODelegate.getInstance())
                .help("Set the output type of the compiler to a MIDI file (.mid). Default: MIDI (.mid)");
        outputFormat.addArgument("--wav", "--wave")
                .dest("IODelegate")
                .action(Arguments.storeConst())
                .setConst(WavIODelegate.getInstance())
                .help("Set the output type of the compiler to a WAVE file (.wav). Default: MIDI (.mid)");

The last argument is a required argument. The file to compile. The given file must exist and be readable.

        argParser.addArgument("file")
                .type(Arguments.fileType()
                        .verifyExists()
                        .verifyCanRead())
                .help("Specify the file to compile.");

Now that the parser is constructed we can parse the input arguments.

        Namespace arguments = null;
        try {
            arguments = argParser.parseArgs(args);
        } catch (ArgumentParserException e) {

If the parse fails forward the error to the parser for a nicely formatted error message and then exit because we don’t have the correct arguments to run the compiler.

            argParser.handleError(e);
            System.exit(1);
        }

Now that we have valid arguments we need to pull the information out of the parser.

        File outDir = handleOutDir(arguments.<File>get("outdir"));
        File toCompile = handleInFile(arguments.<File>get("file"));
        List<Number> tempo = arguments.getList("timesig");

Now we can begin compiling

        Sequence compilationResult = null;
        try {

We want to track the compilation duration so mark the start time

            long startTime = System.nanoTime();

Compile the input file with the given timing arguments.

            compilationResult = compile(toCompile,
                    tempo.get(0).byteValue(),
                    tempo.get(1).byteValue(),
                    arguments.getInt("tempo"),
                    true);

Calculate the compilation time and display it in seconds to 6 decimal places.

            long compileTime = System.nanoTime() - startTime;
            System.out.printf("Compilation took %.6f s\n",
                    compileTime / 1E9d);

Display the duration of the compiled song in seconds.

            System.out.printf("Song length: %d s\n",
                    TimeUnit.MICROSECONDS.toSeconds(compilationResult.getMicrosecondLength()));

If an IOException occurs then we had a problem with the input file. We will display the error and exit.

        } catch (IOException e) {
            System.out.printf("Error reading input file (%s). Reason: %s\n",
                    toCompile.getAbsolutePath(), e.getLocalizedMessage());
            System.exit(1);

If a ParseException occurs then there was a problem with the data in the input file. The input is well formed but the semantics are wrong. Display the error and exit.

        } catch (ParseException e) {
            System.out.printf("Exception encountered while compiling. %s\n", e.getLocalizedMessage());
            System.exit(1);

A ParseCancellationException is different from a ParseException. It is thrown by ANTLR when using the BailErrorStrategy. This is the result of a syntax error. Display the error and exit.

        } catch (ParseCancellationException e) {
            RecognitionException ex = (RecognitionException) e.getCause();
            System.out.printf("Parse exception in rule %s. Offending token: line %d@%d:'%s'. Expected: %s\n",
                    ex.getCtx().toString(ex.getRecognizer()),
                    ex.getOffendingToken().getLine(),
                    ex.getOffendingToken().getCharPositionInLine(),
                    ex.getOffendingToken().getText(),
                    ex.getExpectedTokens().toString(ex.getRecognizer().getVocabulary()));
            System.exit(1);
        }

        try {

If the play flag is set than we will play the compilationResult with the systems midi sequencer.

            if (arguments.getBoolean("play")) {

Tell the user the song is playing.

                System.out.printf("Playing %s\n", toCompile.getName().replace(FILE_EXTENSION, ""));

Create a music player from the sequencer and song.

                SequencePlayer player = new SequencePlayer(MidiSystem.getSequencer(), compilationResult);

Make a blocking call to start playing the song

                player.playSync();

When playSync returns this means the song is done. Tell the user that the play is completed and exit.

                System.out.println("Play complete!");
                System.exit(0);

If the play flag is not set the we will write the result to a file of the same name located in the outdir.

            } else {
                try {

If the compilation result is empty then add the EOT event to make the output file playable.

                    if (compilationResult.getTickLength() == 0) {
                        compilationResult.getTracks()[0].add(new MidiEvent(EOT_MESSAGE, 1));
                    }

                    SequenceIODelegate ioDelegate = arguments.get("IODelegate");

The outFile is a file in the outDir with the same name as the file to compile with the .mid file extension instead.

                    File outFile = new File(outDir, toCompile.getName().replace(FILE_EXTENSION, ioDelegate.getExtension()));
                    if (!outFile.exists()) outFile.createNewFile();

                    long writeStartTime = System.nanoTime();
                    ioDelegate.save(compilationResult, outFile);
                    if (ioDelegate != MIDIIODelegate.getInstance()) {

If we are not writing to a MIDI file then we should inform the user that the expensive operation is converting to sound to the specified type.

                        long writeTime = System.nanoTime() - writeStartTime;
                        System.out.printf("Conversion to %s took %.6f s\n",
                                ioDelegate.getExtension(), writeTime / 1E9d);
                    }

Display the compilation input and output locations for the user also letting them know that the compilation was successful.

                    System.out.printf("%s compiled to %s\n",
                            toCompile.getName().replace(FILE_EXTENSION, ".mlod"),
                            outFile.getPath());

If an IOException occurred let the user know the issue and exit.

                } catch (IOException e) {
                    System.out.printf("Error writing compilation result. Reason: %s.\n", e.getLocalizedMessage());
                    System.exit(1);
                }
            }

If a MidiUnavailableException occurs let the user know the error and exit.

        } catch (MidiUnavailableException e) {
            System.out.printf("Midi system not available. %s.\n", e.getLocalizedMessage());
            System.exit(1);

If an InvalidMidiDataException occurs it is most likely our fault. This error should be reported so ask the user to report it.

        } catch (InvalidMidiDataException e) {
            System.out.printf("Midi compilation error. Something went wrong, please submit your source to" +
                    " the bug tracker. Error: %s\n", e.getLocalizedMessage());
            System.exit(1);
        }
    }

handleOutDir tries its best to use the given outDir and if it can’t it reports the problem to the user and closes the program.

    private static File handleOutDir(File outDir) {

If the directory doesn’t exits try and create it

        if (!outDir.exists()) {
            try {
                boolean created = outDir.mkdirs();

If mkdirs returns false the the directory doesn’t exist because we are only at this point if it didn’t exists in the first place.

                if (!created) {
                    System.out.printf("Could not create outdir %s\n", outDir.getAbsolutePath());
                    System.exit(1);
                }

If a SecurityException occurs let the user know that there is nothing we can do to create the directory for them and close the program.

            } catch (SecurityException e) {
                System.out.printf("Could not create outdir (%s). Stopped by the security manager: %s",
                        outDir.getAbsolutePath(), e.getLocalizedMessage());
                System.exit(1);
            }

If we make it down here than all went well and the directory was created so let the user know we made a new directory on their machine.

            System.out.printf("Created directory %s\n", outDir.getAbsolutePath());

If the path exists but is not a directory then we can’t put the compilation result anywhere. Let the user know they gave us a path to an existing file and close the program.

        } else if (!outDir.isDirectory()) {
            System.out.printf("outdir (%s) is not a directory\n", outDir.getAbsolutePath());
            System.exit(1);
        }

Return the file.

        return outDir.getAbsoluteFile();
    }

handleInFile does its best to use the input file given

    private static File handleInFile(File inFile) {

If the file doesn’t exist then there is nothing to read. Tell the user the problem and exit.

        if (!inFile.exists()) {
            System.out.printf("Cannot find input file %s.\n", inFile.getAbsolutePath());
            System.exit(1);
        }

If the file doesn’t end with .mlod then it isn’t a Mellow D source and the user most likely gave the wrong file. Let them know the file they gave and tell them we can’t use it.

        if (!inFile.getName().endsWith(FILE_EXTENSION)) {
            System.out.printf("In file (%s) is not a Mellow D source file (.mlod).\n",
                    inFile.getAbsolutePath());
            System.exit(1);
        }

Return the input file

        return inFile;
    }

compile is the method that actually runs the compiler.

    public static Sequence compile(File src, byte numerator, byte denominator, int tempo, boolean verbose) throws IOException {

First we will display the inputs being used so they can double check everything is as expected.

        if (verbose) {
            System.out.printf("Time Signature: %d / %d\n", numerator, denominator);
            System.out.printf("Tempo: %d bpm\n", tempo);
            System.out.printf("Compiling file: %s\n", src.getPath());
        }

Now we can build a lexer with the src as the input.

        ANTLRFileStream inStream = new ANTLRFileStream(src.getAbsolutePath());
        MellowDLexer lexer = new MellowDLexer(inStream);

The parser takes the tokens from the lexer as well as the timing environment constructed from the input arguments and a track manager.

        TokenStream tokens = new CommonTokenStream(lexer);
        MellowDParser parser = new MellowDParser(tokens, new TimingEnvironment(numerator, denominator, tempo),
                new TrackManager(GeneralMidiConstants.REGULAR_CHANNELS, GeneralMidiConstants.DRUM_CHANNELS));

We will use the BailErrorStrategy because our compiler is one pass and as soon as a syntax error occurs the parser may be able to recover but the compilation most likely will not.

        parser.setErrorHandler(new BailErrorStrategy());

Parse the input!

        parser.song();

Return the sequence generated while parsing.

        return parser.getSequence();
    }
}
h